feat(tui): add /provider command, custom registry import, and tabbed model selector#264
feat(tui): add /provider command, custom registry import, and tabbed model selector#264sailist wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: f9c847b The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2d7290cd4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| baseUrl, | ||
| apiKey, | ||
| models, | ||
| selectedModelId: '', // no default yet; user picks in the model selector |
There was a problem hiding this comment.
Avoid writing an empty default model
When adding a known provider, this passes an empty selectedModelId into applyCatalogProvider, but that helper always sets config.defaultModel to ${providerId}/${selectedModelId}. If the user presses Esc in the following selector (which the comment says should leave the provider without a default selection), the config has already been saved with an invalid default such as anthropic/, so later config refreshes/startup cannot resolve the default model. Persist the provider/models without setting defaultModel, or clear it before setConfig when no model was selected.
Useful? React with 👍 / 👎.
| applyCustomRegistryProvider( | ||
| config as Parameters<typeof applyCustomRegistryProvider>[0], | ||
| entry as Parameters<typeof applyCustomRegistryProvider>[1], | ||
| source, | ||
| ); |
There was a problem hiding this comment.
Remove the provider before re-importing registry models
For an already-imported custom registry provider, applyCustomRegistryProvider deletes stale aliases only from this in-memory config, but host.harness.setConfig deep-merges patches, so aliases omitted from config['models'] are not removed from the existing TOML. Re-importing an api.json after a model is removed therefore leaves the old alias selectable, unlike the catalog path above which calls removeProvider before applying. Delete each existing provider id before applying its refreshed registry entry, or use a write path that can remove missing model keys.
Useful? React with 👍 / 👎.
| type: entry.type, | ||
| baseUrl: entry.api, | ||
| apiKey: source.apiKey, | ||
| source, |
There was a problem hiding this comment.
Persist the registry source through config validation
Storing source here does not survive the actual setConfig path: provider patches are parsed by ProviderConfigSchema, which only keeps type, apiKey, baseUrl, defaultModel, oauth, env, and customHeaders. After import/reload, ProviderManagerComponent can no longer see cfg.source.kind === 'apiJson', so multiple providers from the same api.json are shown and deleted as unrelated standalone providers instead of one registry source. Add source to the managed provider schema/TOML handling or store the registry metadata in a supported field.
Useful? React with 👍 / 👎.
| // only `adaptive_thinking = true`. Without this it would render as "unsupported" | ||
| // and switching to it would force thinking off. | ||
| if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; | ||
| if (caps.includes('thinking')) return 'toggle'; |
There was a problem hiding this comment.
Preserve adaptive-thinking aliases as toggleable
For aliases configured with adaptiveThinking: true but no explicit thinking capability, this now reports thinking as unsupported and effectiveThinking forces the selection to false. That regresses custom Anthropic endpoints that rely on adaptiveThinking to force adaptive thinking (the provider still forwards that alias field), so selecting the model from /model silently disables thinking instead of preserving/toggling it. Keep model.adaptiveThinking === true in the toggleable case.
Useful? React with 👍 / 👎.
| this.opts = opts; | ||
| this.tabs = buildTabs(opts); | ||
|
|
||
| const preferredProvider = opts.initialTabId ?? opts.models[opts.currentValue]?.provider; |
There was a problem hiding this comment.
Select the tab for an explicit model alias
When /model <alias> names a model from a different provider than the current one, showModelPicker passes that alias as selectedValue, but the tabbed selector still opens the current provider's tab. That tab does not contain the requested alias, so pressing Enter confirms the current model instead of the alias the user typed; prefer opts.models[opts.selectedValue]?.provider before falling back to the current model provider.
Useful? React with 👍 / 👎.
| if (tokenValue.length === 0) { | ||
| this.hint = 'token-empty'; | ||
| this.activeField = 'token'; | ||
| return; |
There was a problem hiding this comment.
Allow tokenless custom registry imports
This dialog refuses to submit when the Bearer token field is empty, but fetchCustomRegistry explicitly supports an empty apiKey by omitting the Authorization header. As a result, users cannot import public/tokenless api.json registries through /provider even though the underlying importer can fetch them. Let an empty token submit as apiKey: '' instead of treating it as a validation error.
Useful? React with 👍 / 👎.
| name: 'provider', | ||
| aliases: ['providers'], |
There was a problem hiding this comment.
Preserve /connect compatibility or mark a major bump
Replacing the /connect registry entry with /provider leaves no compatibility alias for existing users and scripts; after this change /connect ... is no longer recognized as a slash command and falls through as a normal message to the model. The repo rules treat removed commands as breaking changes, so either keep /connect as an alias/forwarder to the provider catalog flow or ship this with an explicit major-version change.
Useful? React with 👍 / 👎.
b2d7290 to
79938e5
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79938e511e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| aliases: ['providers'], | ||
| description: 'Manage AI providers (add / delete / refresh)', | ||
| priority: 95, | ||
| availability: 'always', |
There was a problem hiding this comment.
Make /provider idle-only during generation
When the assistant is streaming or compacting, availability: 'always' lets /provider open and perform add/delete actions even though those callbacks mutate provider config and can call refreshConfigAfterLogin() or clearActiveSessionAfterLogout() without a busy-state guard. In that state, adding a provider may call session.setModel() through the refresh path, and deleting the active provider can close the active session mid-turn; the old provider-mutating /connect command was idle-only by default. Keep /provider idle-only or add an internal streaming/compacting guard before allowing mutations.
Useful? React with 👍 / 👎.
| if (typeof output === 'number' && Number.isInteger(output) && output > 0) { | ||
| return output; |
There was a problem hiding this comment.
Preserve output limits separately from context
When an imported registry model has limit.output but no limit.context, this uses the output-token cap as maxContextSize; for example an 8192 output limit makes the model look like it only has an 8k context window. The parsed output limit is also never written as maxOutputSize on the alias, so runtime provider setup cannot forward it as the model's max-token budget. Leave the context at the default when only output is present and store output in maxOutputSize instead.
Useful? React with 👍 / 👎.
| initialTabId: providerId, | ||
| onSelect: ({ alias, thinking }) => { | ||
| host.restoreEditor(); | ||
| void setDefaultModel(host, alias, thinking); |
There was a problem hiding this comment.
Handle failures while saving the picked default
After adding a catalog provider, this callback fires setDefaultModel() without any catch, but that helper writes config and calls refreshConfigAfterLogin(), which can reject if the config write fails or if activating the newly selected provider fails (for example because the entered API key is invalid). Since this selection happens after the slash-command handler has already returned, the dispatcher’s try/catch does not see the rejection, leaving the user with no error message and potentially an unhandled promise rejection; mirror performModelSwitch and surface the failure through host.showError.
Useful? React with 👍 / 👎.
…model selector - add "/provider" slash command for managing AI providers with CRUD UI\n- add custom registry import via api.json URL and Bearer token\n- introduce tabbed model selector grouped by provider\n- add fetchCustomRegistry and applyCustomRegistryProvider in oauth package\n- replace deprecated "/connect" command with unified "/provider" flow\n- update provider and slash-command documentation
79938e5 to
ccb1060
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ccb10605c0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| aliases: [], | ||
| description: 'Switch LLM model', | ||
| priority: 100, | ||
| availability: 'always', |
There was a problem hiding this comment.
Keep /model blocked while compacting
When the app is compacting (isCompacting true while streamingPhase can still be idle), marking /model as always lets the picker open and then performModelSwitch proceeds because its only busy guard checks streamingPhase !== 'idle'; selecting a model can therefore call session.setModel/setThinking and persist defaults during compaction. Keep this command idle-only or add an isCompacting guard before applying the selection.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
This empty-model notice still tells users to run /connect, but the same change removes /connect from the command registry and dispatch path, so in this exact no-model state the suggested command is no longer executable and will be treated as normal input. Point users at /provider instead so they can actually add a provider from the TUI.
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let config = await host.harness.getConfig(); | ||
| for (const entry of Object.values(entries)) { | ||
| if (config.providers[entry.id] !== undefined) { | ||
| config = await host.harness.removeProvider(entry.id); |
There was a problem hiding this comment.
Preserve earlier imports when replacing registry providers
When re-importing a custom registry that contains multiple providers already present in the config, this call reloads the config from disk on each iteration. Any provider applied earlier in the loop has not been persisted yet, so a later removeProvider() overwrites config with a snapshot that does not include that earlier in-memory addition; after the final setConfig, only the providers applied after the last removal survive. Remove all conflicting providers before applying entries, or delete stale providers/models from the in-memory config instead of replacing config mid-loop.
Useful? React with 👍 / 👎.
| description: 'Manage AI providers (add / delete / refresh)', | ||
| priority: 95, | ||
| availability: 'always', | ||
| }, |
There was a problem hiding this comment.
Keep provider management idle-only during active turns
Marking /provider as always lets users open it while a response is streaming or compaction is running, but the add/delete flows mutate the active runtime: picking a default after adding a provider goes through setDefaultModel() → refreshConfigAfterLogin() → activateModelAfterLogin(), which calls session.setModel() without the non-idle guard used by /model, and deleting the active provider can close the session. This can change or tear down the current session while a turn is still in flight, so provider mutations should stay idle-only or guard these actions explicitly.
Useful? React with 👍 / 👎.
| aliases: [], | ||
| description: 'Switch LLM model', | ||
| priority: 100, | ||
| availability: 'always', |
There was a problem hiding this comment.
Keep /model idle-only until streaming switches work
With this availability change, /model is no longer blocked during streaming, so the user can spend time in the selector, but selecting a model still immediately hits performModelSwitch()'s streamingPhase !== 'idle' check and only shows Cannot switch models while streaming. Either keep the command idle-only or remove the later guard so the advertised always-available workflow can actually complete.
Useful? React with 👍 / 👎.
| models: config.models, | ||
| }); | ||
|
|
||
| await host.authFlow.refreshConfigAfterLogin(); |
There was a problem hiding this comment.
Refresh providers without reactivating the saved default
At this point the provider has only been added and the user has not picked the new default yet, but refreshConfigAfterLogin() also calls activateModelAfterLogin() for whatever config.defaultModel currently says. If the current session is using a runtime-selected model that differs from the saved default, simply adding a provider can silently switch the session back before the selector appears (and canceling the selector leaves it there); use a model/provider snapshot refresh here and only activate after setDefaultModel().
Useful? React with 👍 / 👎.
| const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); | ||
| let catalog: Catalog | undefined; | ||
| try { | ||
| catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal); |
There was a problem hiding this comment.
Preserve the built-in catalog fallback for provider add
The new known-provider path always fetches DEFAULT_CATALOG_URL and returns on failure, while the removed /connect path first used the bundled catalog and could fall back to it when the network was unavailable. Since /connect is no longer reachable, users behind a proxy or offline can no longer add catalog providers even though the built-in catalog is still shipped; load BUILT_IN_CATALOG_JSON as the default/fallback here or keep an equivalent command path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7ed23e0ed
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let changedAny = false; | ||
|
|
||
| for (const providerId of providerIds) { | ||
| const entry = entries[providerId]; |
There was a problem hiding this comment.
Match custom registry entries by entry id
For custom registries whose top-level object key differs from the provider id, this lookup never finds the refreshed entry: fetchCustomRegistry returns a record keyed by the top-level key, while imported providers are stored under entry.id (the parser comment also notes these may differ). In that case startup/background refresh silently skips the provider, so model additions/removals from that registry are never applied; iterate the fetched values and match entry.id to providerId instead.
Useful? React with 👍 / 👎.
| applyOpenPlatformConfig(asManaged(config), { | ||
| platform, | ||
| models, | ||
| selectedModel, |
There was a problem hiding this comment.
Preserve saved defaults during platform refresh
When a non-default Open Platform provider has a model-list change during the startup refresh, pickDefaultModel() falls back to that provider's first model unless the existing default already belongs to the same provider, and applyOpenPlatformConfig() then writes that alias into config.defaultModel. Because this patch persists defaultModel, merely refreshing an unrelated Moonshot Open Platform account can replace the user's saved default model for the next launch; preserve the previous default unless it pointed at one of the refreshed provider's removed aliases.
Useful? React with 👍 / 👎.
| activeProviderId, | ||
| colors: host.state.theme.colors, | ||
| onAdd: () => { | ||
| void handleProviderAdd(host); |
There was a problem hiding this comment.
Report failures from the add-provider callback
After the manager is already mounted, pressing the add row runs handleProviderAdd() as a fire-and-forget promise, so the slash-command dispatcher's try/catch no longer observes failures. If the catalog add path later rejects while writing config or refreshing the newly added provider, the user gets no host.showError() and the rejection can become unhandled; catch this promise here the way deletion errors are handled.
Useful? React with 👍 / 👎.
| unchanged.push(KIMI_CODE_PROVIDER_NAME); | ||
| } else { | ||
| const { added, removed } = computeChanges(beforeIds, newIds); | ||
| config = await host.removeProvider(KIMI_CODE_PROVIDER_NAME); |
There was a problem hiding this comment.
Preserve the selected Kimi model during refresh
When the managed Kimi model list changes, removeProvider() deletes all Kimi aliases and clears config.defaultModel if it pointed at one of them before applyManagedKimiCodeConfig(..., preserveDefaultModel: true) runs. That makes the preserve flag ineffective, so a user whose saved default is still available can be reset to the first returned Kimi model on the next startup refresh; keep the previous default alias before removing the provider and restore it when it is still in the refreshed list.
Useful? React with 👍 / 👎.
| const beforeIds = collectModelIdsForProvider(config, providerId); | ||
| const newIds = new Set(models.map((m) => m.id)); | ||
|
|
||
| if (setsEqual(beforeIds, newIds)) { |
There was a problem hiding this comment.
Refresh model metadata when ids are unchanged
For an Open Platform refresh where /models returns the same model IDs but updated metadata such as context_length, display name, or reasoning/tool support, this branch marks the provider unchanged and skips rewriting the aliases. The TUI/runtime will then keep stale maxContextSize and capabilities (for example using an old compaction window) until a model is added or removed; compare the alias metadata or reapply the provider config even when the id set is equal.
Useful? React with 👍 / 👎.
b7ed23e to
784253b
Compare
- add `refreshAllProviderModels` utility supporting managed OAuth, open platforms and custom registries\n- wire background refresh into `KimiTUI` startup via `AuthFlowController`\n- add `providerSwitchHint` option to `ModelSelector` and enable it in `TabbedModelSelector`\n- update `TabbedModelSelector` hint wording from "switch" to "provider"
784253b to
f9c847b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f9c847ba40
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (setsEqual(beforeIds, newIds)) { | ||
| unchanged.push(providerId); |
There was a problem hiding this comment.
Refresh custom registry metadata when IDs are unchanged
When a custom registry returns the same model IDs but changes metadata such as limit.context, capabilities, display name, or the provider api URL, this branch records the provider as unchanged and skips applyCustomRegistryProvider(). The startup refresh then leaves stale aliases/provider config in place, so the TUI/runtime can keep using an old context window or endpoint until a model is added or removed; reapply the entry or compare the full alias/provider metadata instead of only the ID set.
Useful? React with 👍 / 👎.
| for (const providerId of providerIds) { | ||
| const entry = entries[providerId]; | ||
| if (entry === undefined) continue; |
There was a problem hiding this comment.
Synchronize provider additions and removals from registries
When the api.json source adds a new top-level provider or removes one that was previously imported, this refresh loop only visits the provider IDs already in config and silently skips missing entries. That leaves removed providers selectable forever and never imports newly published providers from the same registry source during startup refresh; compare the fetched entry set with providerIds and apply/remove providers at the source level instead of continuing here.
Useful? React with 👍 / 👎.
| config.providers[providerKey] = { | ||
| type: entry.type, | ||
| baseUrl: entry.api, | ||
| apiKey: source.apiKey, | ||
| source, |
There was a problem hiding this comment.
Do not reuse the registry token as the provider API key
For custom registries where the api.json fetch token is not the same credential used by the model endpoint (including public registries with provider credentials named in entry.env), this writes source.apiKey into the provider config and drops the parsed entry.env. The runtime later resolves provider auth from provider.apiKey/standard env keys, so imported models can send the registry bearer token—or no token at all—to the provider API; preserve the provider credential source separately instead of copying the registry fetch token here.
Useful? React with 👍 / 👎.
| config = await host.removeProvider(providerId); | ||
| removeCustomRegistryProvider(asManaged(config), providerId); | ||
| applyCustomRegistryProvider(asManaged(config), entry, source); |
There was a problem hiding this comment.
Preserve custom registry defaults during refresh
When a custom-registry provider's model set changes, this call clears config.defaultModel on disk whenever the saved default belongs to that provider, even if that alias is still present in the refreshed registry. applyCustomRegistryProvider() does not restore a default and the later setConfig() omits defaultModel, so adding or removing an unrelated model from the registry can silently drop the user's saved default for the next launch; keep the previous default when it still exists after reapplying the entry.
Useful? React with 👍 / 👎.
Summary
This PR introduces a comprehensive provider/model management system for the TUI, including a new
/providerslash command, a tabbed model selector, custom registry import support, and supporting UI dialogs. It also adds corresponding documentation updates and unit tests.1. Provider & Model Management in TUI
Problem: Users previously had limited ability to manage LLM providers and select models directly from the TUI. Switching providers or importing custom registries required manual configuration file edits.
What was done:
/providerTUI slash command for managing providers interactively.ProviderManagerDialogto list, add, edit, and remove providers.TabbedModelSelectorto let users browse and select models across providers in a tabbed interface.2. Custom Registry Import
Problem: There was no built-in way to import external provider registries (e.g., custom OpenAI-compatible endpoints) without manually editing configuration files.
What was done:
CustomRegistryImporterUI dialog for importing registries via URL or pasted JSON.packages/oauth/src/custom-registry.tsmodule to parse, validate, and merge custom registry definitions.packages/oauth/test/custom-registry.test.ts.3. Documentation & Test Updates
Problem: New TUI commands and registry features lacked user-facing documentation, and existing tests needed updates for the changed command surface.
What was done:
choice-picker.test.ts,kimi-tui-message-flow.test.ts) to reflect new command additions.Checklist
make gen-changelogto update the changelog.make gen-docsto update the user documentation.